今天將複習 C 語言中對於檔案的基本操作,以及檔案中一些重要的概念,如重新導向,資料流等等,最後看到 xv6 中檔案的一些操作,System call,以及檔案系統的大致介紹。藉由比較 C 語言和 xv6 中檔案操作的相關使用,了解到 C 語言背後作業系統考量的相關設計。
C 將每一個檔案視為連續的位元組串流,而這樣的串流又區分為文字檔案流和二進位檔案流,而對於每一種流,C 語言中分別有對應的函式進行處理。
標頭檔 stdio.h 宣告了三種型別,分別為 FILE,fpos_t,size_t,一些巨集 (macros),和函數用來表示及處理 Input 和 Output。
以下為 FILE 結構在 stdio.h
中的具體定義( FILE並非關鍵字(keyword) )
typedef struct _iobuf
{
char* _ptr;//目前緩衝區的位置
int _cnt;//緩衝區中可使用的字元數量
char* _base;//緩衝區記憶體起始地址
int _flag;//流的狀態,檔案讀寫模式,用於告知kernel
int _file;//檔案描述子
int _charbuf;//緩衝區剩餘的個數(offset的概念)
int _bufsiz;//緩衝區的大小(位元組)
char* _tmpfname;//暫時檔案名稱
} FILE;
FILE 是 C 語言檔案結構定義 (object type),本質上為一種結構 (struct),能夠紀錄控制一個物件 (檔案) 所需要的所有資訊 (包含流在內),而其中的流 (stream) 又包含檔案位置指示符,指向其關聯的緩衝區 (如果存在緩衝區) 的指標,錯誤指示符,記錄檔案是否發生讀寫錯誤以及檔案結尾指示符 (EOF),指示是否到達檔案的尾端。
fpos_t是一種結構 (struct),代表這個檔案的位置 (不能使用在 array上)。
舉例來說,假設開啟一個檔案,而他的讀寫位置是從檔案開始位置到往後 20 bytes 後的地方,那麼呼叫fgetpos (pFile, &pos)
之後,pos 的值就會是 20。而其中 pos 的型別就是 fpos_t *
。
從這個例子我們得知幾件事情,fpos_t
這個型別不能夠直接呼叫來取得檔案位置,而是作為函數的參數 (parament) 進行呼叫,其實在定義上,fpos_t
本質上就是作為fsetpos()
或fgetpos()
等函數參數的型別使用,而第二件事情為這個結構也是透過存取指標的方式對檔案位置進行處理。
作為無號整數 (Unsigned integral type) 的另一種名稱,一般來說作為 sizeof 的回傳型別使用。
fopen()
,與 xv6 open()
System callFILE *fopen(const char *filename, const char *mode);
用流的方式開啟檔案,這時候我們會呼叫 fopen 函式。
第一個參數 (parameter) 為名稱為 filename,指向唯讀 char 型別的指標變數,filename 可能包含檔案位置訊息,路徑之類的。
第二個參數為名稱 mode,指向唯讀 char 型別的指標變數,作用為說明對於檔案進行的操作內容,含有以下幾種
而在 xv6 中,當我們使用 open()
這個 System call,我們也會需要傳入檔案開啟的模式,在 xv6 的 Shell 中我們也會看到類似的操作
fd = open("console", O_RDWR);
而檔案的開啟模式我們可以從 xv6 中kernel/fcntl.h
看到
#define O_RDONLY 0x000
#define O_WRONLY 0x001
#define O_RDWR 0x002
#define O_CREATE 0x200
#define O_TRUNC 0x400
思考 : 什麼叫做 write only ? 不是要 read,才能夠 write 嗎? 像是打開 word 然後寫入,不是一定要先讀取才能寫入嗎 ?
對於一些特殊檔案,如 log,只能夠允許使用者對這個 log 添加一些 entry,但不允許使用者讀取其他 entry,這也就是只寫不讀。
< Write access without read access >
write 會覆蓋原本的數據,導致原本的數據消失。
append 會將想要寫入到檔案的數據,在檔案末端 (EOF) 進行寫入,可以保留原本的數據。
update 為讀寫 (read and write)的意思
如果檔案成功開啟,則會回傳指向 FILE 型別的指標,這個指標可以流用於後續操作
如果開啟失敗,則會回傳 NULL。
fopen()
通常使用,會將回傳的指標放到一個變數中,後面需要再對檔案進行操作時在來使用他
一般操作如下:
FILE *fptr = fopen("input.txt", "r");
當後面程式要從 input.txt 進行操作時,將會使用 fptr 作為引數來使用。
fclose()
,xv6 System close()
int fclose(FILE *stream);
fclose()
讓程式關閉不再使用的檔案 (關閉與流有關的檔案,並取消其關連,也就是將指標指向緩衝區歸還),fclose()
函數的參數為檔案指標,這個指標來自於 fopen()
或是 freopen()
的呼叫。
如果成功關閉,則回傳整數 int 0,如果關閉失敗則回傳 EOF (定義於stdio.h 的巨集)。
這裡的關閉會將所有與檔案流有關的內部緩衝區取消與檔案流的關聯,使這一些緩衝區釋放出去,給其他程式進行調用。
下面為檔案開啟與關閉的示範
#include <stdio.h>
#include <stdlib.h>
#define FILE_NAME "example.dat"
int main(void)
{
FILE *fp;
fp = fopen(FILE_NAME, "r");
if(fp == NULL)
{
printf("can't open the file\n");
}
else
{
printf("sucess");
}
fclose(fp);
return 0;
}
這一段簡單的程式示範了檔案開啟與關閉,值得我們注意的一點,一般來說不會將fopen()
的呼叫和 fp 的宣告寫在一起。但 xv6 中許多寫在一起的寫法
或是像下面這樣
if((fp = fopen(FILE_NAME, "r")) == NULL)...
freopen()
freopen()
的用途為重新使用流 (stream),將流指向新的文件或是更改他的模式 (mode),同時關閉 (取消關聯) 舊的流。
以下為 freopen()
的函式原型
FILE *freopen(const char *filename, const char *mode, FILE *stream);
如果我們將流指定了新的檔案,則 freopen()
會關閉流指向的所有檔案,並解除流和檔案的關聯,及流不再指向到檔案。接著,無論流是否成功關閉其所關聯的檔案,freopen()
都會打開 filename 所指定的檔案,並將這個新的檔案和流產生關聯,也就是流指向新檔案,就和 fopen()
使用指定的模式打開檔案的行為是相同的。
freopen()
常見的應用是將預設的資料流 (stdin, sudout, stderr),重導向(請見上方說明)到特定的文件。以下為例
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* fp;
printf("This text is redirected to stdout\n");
fp = freopen("file.txt", "w+", stdout);
printf("This text is redirected to file.txt");
fclose(fp);
return 0;
}
而 freopen()
的回傳值和 fopen()
是相同的,如果成功開啟檔案,回傳該檔案的指標,如果回傳失敗,則回傳 NULL。
fprintf()
int printf(const char* format);
int fprintf(FILE* stream, const char* format);
我們在先前已經會使用 printf()
,現在,我們下面要來解釋一下printf()
和 fprintf()
。
fprintf()
為輸出到流的函式,並運用格式化決定輸出的形式,printf()
為輸出指定型別變數的函式,也是運用格式化方式決定輸出。
而前面說過,檔案被視作為一個流 (stream),因此,我們可以使用 fprintf()
將輸出寫入到一個檔案之中
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
FILE *output;
output = fopen("output.txt", "w");
fprintf(output, "Hello World");
}
output.txt
Hello World
說明:
我們使用了 fopen,得到了 output.txt 的檔案指標,也就是所謂檔案流,使用 fprintf 將 Hello World 輸出到 output 這個檔案流中,也就是將 Hello World 寫入到 output.txt 中,注意,fopen 需要將 output.txt 以 write 模式開啟,才可以寫入
同理,我們知道C語言在執行程式時,會自動開啟三個資料流,stdin, stdout, stderr,所謂 stdout,預設就是終端機的畫面,因此,我們可以通過以下方式,將 Hello World 輸出到終端機上面
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
fprintf(stdout, "Hello World");
}
我們也可以輸入字串,將字串寫入到檔案中。而在下方介紹 xv6 時,我們將會看到其中也有使用到類似的操作。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
FILE *output;
char buffer[100];
scanf("%s", buffer);
input = fopen("output.txt", "w");
fprintf(output, "%s", buffer);
}
輸入
YoYo
output.txt
YoYo
說明:
我們使用 buffer 這個字元陣列接收我們的輸入,將輸入存到 buffer 中,第11行將 output.txt 以讀取模式開啟,第12行,將 buffer 的內容,以格式化字串的方式,寫入到 output 指向的資料流中,也就是 output.txt。
fscanf()
int fscanf(FILE *stream, const char *format, ...)
從流中,以格式化方式讀入資料
input.txt
Hello World
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
FILE *input;
char buffer[100];
input = fopen("input.txt", "r");
fscanf(input, "%s", buffer);
printf("%s", buffer);
}
輸出
Hello
說明:
我們使用 buffer 來儲存 input.txt 檔案中的資料,第10行,fopen()
以讀取模式開啟 input.txt,第11行,將 input 的內容,以字串的方式讀取,儲存到buffer中,在第12行中印出,可以看到我們只讀取到 Hello World 中的 Hello,這是因為fscanf()
也是碰到空格就會停下來了,因此,我們可以使用fgets()
來解決。
fgets()
char *fgets(char *str, int n, FILE *stream)
讀取資料流其中一行的 n - 1 個字元,儲存到 str 中。
input.txt
Hello World
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
FILE *input;
char buffer[100];
input = fopen("input.txt", "r");
fgets(buffer, 100, input);
printf("%s", buffer);
}
輸出
Hello World
說明:
第11行,fgets()
將 input 的資料,讀取最多 100 - 1 個字元,將資料儲存到 buffer 中。而這個可以控制最多讀取多少個字元,正是 fgets()
安全的地方(避免越界),我們可以實驗看看。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
FILE *input;
char buffer[100];
input = fopen("input.txt", "r");
fgets(buffer, 2, input);
printf("%s", buffer);
}
輸出
H
說明:
最多讀取2 - 1個字元,因此只讀取到 H。
檔案系統主要功能為隱藏與抽象硬碟與 I/O 裝置的一些細節,做到作業系統中抽象化的部分,如建立檔案,讀寫檔案,刪除檔案等等,皆需要 System call,而在檔案讀取之前,我們需要機械硬碟先定位檔案所在的位置等等,System call 幫助我們完成這一些操作。
在作業系統中,常常使用到目錄 (directory) 的概念,例如檔案系統 (file management),在 windows 中為檔案總管,提供了一個保存檔案,以及定位檔案的地方。目錄結構也可以如同 Process 產生出一棵 Tree,根結點為根目錄 /root,底下的子樹為其他目錄,葉節點為檔案。而與 Process Tree 的差別為深度較深,且存在時間十分的長。在 Process Tree 中,只有親代 Process 可以控制子 Process,而在檔案目錄系統中也有類似的保護機制,使得檔案只能被特定的使用者進行讀寫。
在讀寫檔案之前,首先會打開檔案,檢查存取權限,如果權限許可,會回傳一個整數,該整數被稱為檔案描述子 (file descriptor),提供後續的操作使用。
在 UNIX 中有一個重要的想法,everything is a file,UNIX中有兩種 special file,分別為 block special file 和character special file,可以將 I/O 裝置被看待成檔案,並儲存在檔案系統中,好處為可以使用 System call 對於這一些 I/O 裝置進行操作,以及方便存取這一些裝置。
如果我們想要讓檔案或是 Process 之間交換資訊,我們可以通過pipe()
達成,pipe 為一種檔案,當 Process A 和 B 希望通過pipe 交換資訊,他們需要提前設置兩者之間的 pipe。當 Process A 想要傳送資訊到 Process B,會將資訊寫入到 pipe 上,就像是將輸出寫入到檔案上,這樣 Process B 就可以通過讀取 pipe 來獲取資訊。這樣的機制可以讓 UNIX 中兩個 Process 的通訊看起來像是普通的檔案讀寫一樣。
C將每一個檔案視為連續的位元組串流,而這樣的串流又區分為文字檔案流和二進位檔案流,當一個檔案開啟時,就會有一個資料流 (Stream) 和這個檔案結合在一起。在程式開始執行時,會有三個資料流自動開啟,分別為
舉一個實際例子,假設我們想寫一個複製文件的程式,就是把一個已存在的文件複製一遍再放回去,那會有幾個問題:
1: 你想要複製的那個文件是怎麼輸入到你的程式裡的?
2: 複製完了以後要放到哪裡去?
3: 假設文件中有兩行文字,是複製第一行文字,再將第一行文字寫入到複製的文件中,再將第二行文字複製,並寫入到複製的文件中,還是一次選取兩行,將他們一起寫入到複製的文件中
4: 我們在過程中使用的是文字檔案流,還是使用二進位檔案流?
C 語言就是利用流 (Stream) 來處理上面這一些問題的。
精簡說明流 (Stream) 的一句話,就是一段記憶體空間,有開始,有結束。
流 (Stream) 被定義成指標 (Pointer),指標我們知道就是存放記憶體地址的變數,以輸入作為說明,stdin 就是預設的輸入流,通常來說就是以鍵盤的輸入,也就是你的程式會像鍵盤索取要複製的文件,stdin 就是指向鍵盤這個輸入設備的指標,而針對 stdin 這個指標,stdio 也有提供相對應的函數處理,如果輸入是字符 (char),就使用 getchar()
,文字用gets()
,scanf()
,二進位檔案中的數據使用fread()
,基本上流這是一個這樣的概念。
EOF 為一個整數常數的表示式,其型別為 int 且是一個負值 (native),被許多函式 (function) 用來表示檔案的結束,或是用來表示輸入流 (Stream) 的結束。
一連串的記憶體 (Stream),在C語言中是被視為檔案的存在,在程式執行時,會自動打開三個檔案流,stdin, stdout, stderr,前面提到 scanf()
會將先將使用者的輸入放到緩衝區中,而 stdin 就是作為 scanf()
的緩衝區使用,EOF 的作用是標記緩衝區 (檔案) 的結束。
所有設備對於檔案的操作都使用檔案描述子来進行的。
檔案描述子是一個非負整數,本質上為一個索引值 (key) ,指向 kernel 為每一個 Process 打開的紀錄表,這個紀錄表讓 kernel 知道每一個檔案描述子對應到的實際內容是什麼。這個非負整數表示一個 Process 可以讀取或是寫入的檔案,Process 可以通過開啟檔案 (I/O 設備也視為一種檔案),或是開啟目錄,或是 pipe()
等方式獲得檔案描述子。
當打開一個已存在的檔案或是建立一個檔案時,內核 (kernal) 就會向處理程序回傳一個檔案描述子用於之後對於文件的讀寫 (I/O) 操作,當需要讀寫文件時,也需要將檔案描述子作為引數 (argument) 傳遞給函式
在一般情況下,一個程式從硬碟載入到記憶體後,這個程式就會轉變為處理程序 (process),這時候系統會預設開啟三個資料流 (stream) (也可稱作檔案,檔案做為流),分別為 stdin (標準輸入),stdout (標準輸出),stderr (標準錯誤)。
而這三個檔案對應到的檔案描述子分別為 0,1,2,所以後面建立的檔案,他的檔案描述子就是從3開始了,原因是因為在 Linux 系統中,會由小到大去一個個查詢檔案描述子是否已經使用,再進行分配。
每一個 Process 都有自己的記憶體空間,有各自的獨立用來存放檔案描述子的空間,如果有兩個 Process 都開啟了一個檔案,有可能這兩個 Process 會有相同的檔案描述子,但是對應到不同的檔案。
下面將看到 xv6 中對於檔案的一些操作,可以看到許多的概念與上面提及的 C 語言中檔案操作中有許多相似之處。
C 程式一開始會自動開啟 stdin, stdout, stderr,而在 xv6 的 Shell 也有類似的行為,可以在/user/sh.c
中看見
while((fd = open("console", O_RDWR)) >= 0){
if(fd >= 3){
close(fd);
break;
}
}
下面為一個實驗Tcopy.c
,來了解檔案描述子在 xv6 中的使用
#include "kernel/types.h"
#include "user/user.h"
int main(void)
{
char buffer[64];
while(1){
int n = read(0, buffer, sizeof(buffer)/sizeof(char));
if(n <= 0)
break;
write(1, buffer, n);
}
exit(0);
}
output:
$ Tcopy
abc
abc
說明:
我們在 xv6 的 Shell 執行 Tcopy 這個程式,他會等待使用者輸入,並且將使用者的輸入輸出到 Console 上。
第9行的read()
,我們需要傳入三個引數 (Argument)
第一個參數為檔案描述子,指向一個先前我們打開的檔案,而在 Shell 剛開始執行的時候,會開啟三個檔案,在上面的sh.c
我們可以看見,而開啟的檔案分別為
而因為 Console 做了這一些操作,因此我們可以通過 Console 看到我們的輸入與輸出
第二個參數為指向某一段記憶體空間的指標,程式可以通過這個指標指向的記憶體地址獲得在記憶體中的資料,這裡指向的為 buffer,buffer 為區域變數,程式向 stack 申請了 64 bytes 的記憶體空間,並將指向該記憶體空間的指標儲存在 buffer 中。而 read 有指向 buffer 的指標,因此 read 可以把資料保存在這一段記憶體空間中。
第三個參數為讀取的最大長度,這裡使用sizeof
運算子來取得 buffer 的大小。所以在這裡,Console 通過連接到檔案描述子為0的檔案(也就是標準輸入,重新導向到 Console 上)讀入最多 64 bytes 的資料。
read()
的回傳值為讀取到的 byte 數,輸入 abc 則 read()
的回傳值為3,如果 read()
是從檔案中讀取資料,如果碰到了檔案結尾,也就是 EOF(End Of File),則可能沒有任何資料被read讀取近來,因此回傳值為0。而如果 read()
欲開啟的檔案不存在,read()
可能會回傳一個負整數。
write()
將 buffer 的內容寫入到檔案描述子1的檔案中,這裡我們將其指向到 Console 上,所以我們可以在 Console 看到 write()
寫入的輸出。
這裡不論是 read()
或是 write()
,都是將檔案視為流,也就是以一個固定的大小不斷的去讀取檔案,這裡是以1 bytes 不斷的進行讀取並且寫入。輸入流的本質 : 指標指向來源,根據來源的檔案或是變數結構來選擇要使用什麼函數對其進行處理,指標再根據一串一串的流不斷的進行指標轉型直到整段流處理完畢。
而我們也可以讓 write()
不要寫入到 Console 上,而是在某一個檔案上,作法就是我們提供目標檔案的檔案描述子給 write()
,在上面我們給定 write()
的檔案描述子為1,1為標準輸出,由 Console 所開啟,因此輸出會顯示在 Console 上,而如果要顯示在檔案中,我們可以通過open()
System call 來實現。
#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"
int main(void)
{
int fd = open("output.txt", O_WRONLY | O_CREATE);
write(fd, "123\n", 4);
exit(0);
}
這裡可以注意到有兩個巨集定義,O_WRONLY
和O_CREATE
,定義位於fcntl.h
中
#define O_RDONLY 0x000
#define O_WRONLY 0x001
#define O_RDWR 0x002
#define O_CREATE 0x200
#define O_TRUNC 0x400
這個巨集定義表示open()
這個 System call 中在 kernel 應該要有怎樣的行為,這一些巨集定義作為控制open()
行為的標示符(flag)。open()
通過O_WRONLY | O_CREATE
讓 kernel 得知要建立並且寫入一個檔案 (就像 C 語言中的 fopen()
)。
呼叫完open()
之後open()
會回傳一個檔案描述子提供我們使用,這個檔案描述子可能為2以上的數字(因為0, 1, 2都被用掉了),我們可以通過這樣的方式讓資料寫入到特定的檔案中。
cat()
而我們可以看看/user/cat.c
是如何進行實作的,cat()
的功能為從檔案讀取資料並且輸出到 stdout
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
char buf[512];
void cat(int fd)
{
int n;
while((n = read(fd, buf, sizeof(buf))) > 0) {
if (write(1, buf, n) != n) {
fprintf(2, "cat: write error\n");
exit(1);
}
}
if(n < 0){
fprintf(2, "cat: read error\n");
exit(1);
}
}
int main(int argc, char *argv[])
{
int fd, i;
if(argc <= 1){
cat(0);
exit(0);
}
for(i = 1; i < argc; i++){
if((fd = open(argv[i], 0)) < 0){
fprintf(2, "cat: cannot open %s\n", argv[i]);
exit(1);
}
cat(fd);
close(fd);
}
exit(0);
}
在第33行可以看到使用了open()
System call 取得了欲開啟檔案的檔案描述子,放到 fd 中,接著執行第37行將檔案描述子傳入函式cat(int fd)
。
在第11行使用 read()
讀取目標檔案,獲得目標檔案的方式為通過其檔案描述子,並且執行第12行的 write()
,write()
的第一個參數為檔案描述子,而這裡的引數為1,檔案描述子1對應到的為標準輸出,因此我們可以知道cat.c
的行為是讀取目標檔案,並將其檔案內容輸出到標準輸出上,而由於 Shell 一開始會自動開啟標準輸出,因此我們可以在Console 上面直接看到檔案內容。
檔案名稱與檔案並不同,檔案在xv6中以inode作為別稱,一個inode可以有很多個名稱,而這個名稱稱為links,links由檔案名稱和對inode的參考所組成,inode為一個結構(struct),內容包含檔案類型(總共有三個類型,分別為目錄,檔案,裝置,使用define定義),檔案長度,檔案內容,以及在硬碟中位置,檔案鏈接的數量,整個結構定義在kernel/stat.h
中。
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // File system's disk device
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file
uint64 size; // Size of file in bytes
};
而System call,link()
有兩個參數,為檔案名稱a和b,link()
的功用為將檔案名稱b指向到與檔案名稱a同一個inode,因此讀寫檔案a就相當於讀寫檔案b,每一個inode都有一個標示號,為uint ino
,可以通過ino
查詢到檔案鏈結的數量,在上面a和b鏈結到同一個inode,因此nlink
為2。而可以通過link()
鏈結名稱,相反的也可以通過ulink()
解除鏈結。
xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book
C Programming: A Modern Approach, 2/e
Basics of File Handling in C